Architecture &
Core Concepts
You said you're strong in Angular. This guide goes DEEP โ not basics. Every property, every concept, with exactly the right depth for your 3yr experience.
Sections
Concepts
Modern Angular
๐๏ธ Angular's Building Blocks (Big Picture)
An Angular app = a tree of Components. Components have Templates (HTML). Services handle logic & data. Modules group related things. Routing maps URLs to components. Directives add behavior to HTML. Pipes transform display data. RxJS handles async streams. DI provides dependencies where needed.
Components
The fundamental building block. Everything in Angular is a component or relates to one.
@Component({ // REQUIRED selector: 'app-user-card', // CSS selector: element/attr/class templateUrl: './user-card.html', // OR template: `...` inline styleUrls: ['./user-card.scss'], // OR styles: [`...`] inline // CHANGE DETECTION changeDetection: ChangeDetectionStrategy.OnPush, // PERFORMANCE! // ENCAPSULATION encapsulation: ViewEncapsulation.Emulated, // default: adds unique attrs // ViewEncapsulation.None โ styles are global // ViewEncapsulation.ShadowDom โ real shadow DOM // STANDALONE (Angular 14+) standalone: true, imports: [CommonModule, RouterModule], // for standalone // HOST host: { 'class': 'my-component', '(click)': 'onClick()' }, // ANIMATIONS animations: [trigger('fadeIn', [...])], })
// Basic @Input() title: string = ''; // With alias (parent uses 'data', internally it's 'item') @Input('data') item!: User; // Required (Angular 16+) โ throws error if not passed @Input({ required: true }) userId!: string; // Transform (auto-convert string โ number from template) @Input({ transform: numberAttribute }) count!: number; @Input({ transform: booleanAttribute }) disabled!: boolean; // With setter โ run logic when value changes private _name = ''; @Input() set name(val: string) { this._name = val.trim().toUpperCase(); } get name() { return this._name; } // Signal input (Angular 17+) count = input(0); // optional, default=0 name = input.required<string>(); // required signal input
// Child component export class UserCardComponent { @Output() selected = new EventEmitter<User>(); @Output() deleted = new EventEmitter<string>(); // emit ID onSelect(user: User) { this.selected.emit(user); } } // Parent template <app-user-card (selected)="handleSelect($event)" (deleted)="removeUser($event)" /> // Parent component handleSelect(user: User) { /* $event is the User object */ } // output() โ Signal API (Angular 17+) export class NewCardComponent { selected = output<User>(); onSelect(u: User) { this.selected.emit(u); } }
@ViewChild: Access a child component, directive, or DOM element from the component's own template.
@ContentChild: Access projected content (ng-content) passed INTO this component.
export class ParentComponent implements AfterViewInit { // Get child COMPONENT instance @ViewChild(ChildComponent) child!: ChildComponent; // Get DOM element via template ref @ViewChild('myInput') inputEl!: ElementRef<HTMLInputElement>; // All matching children (plural) @ViewChildren(ChildComponent) children!: QueryList<ChildComponent>; // NOT available in constructor or ngOnInit! ngAfterViewInit() { this.inputEl.nativeElement.focus(); // โ Safe here this.child.someMethod(); } } // ContentChild: content projected via <ng-content> @ContentChild(HeaderComponent) header!: HeaderComponent; // Available in ngAfterContentInit()
<!-- card.component.html --> <div class="card"> <div class="card-header"> <ng-content select="[card-title]"></ng-content> <!-- named slot --> </div> <div class="card-body"> <ng-content></ng-content> <!-- default slot --> </div> </div> <!-- Parent usage --> <app-card> <h2 card-title>My Title</h2> <!-- goes to named slot --> <p>This goes in the default slot</p> </app-card> <!-- ngTemplateOutlet: dynamic templates --> <ng-container *ngTemplateOutlet="myTemplate; context: {$implicit: item}"> </ng-container>
Templates & Directives
Everything that happens inside the HTML of a component.
<!-- *ngIf (old) / @if (new Angular 17+) --> <div *ngIf="isLoggedIn; else guestBlock">Welcome!</div> <ng-template #guestBlock><p>Please login</p></ng-template> @if (isLoggedIn) { <p>Welcome!</p> } @else { <p>Login</p> } <!-- *ngFor / @for --> <li *ngFor="let item of items; let i=index; trackBy: trackById"> {{i}}: {{item.name}} </li> @for (item of items; track item.id) { <li>{{item.name}}</li> } @empty { <p>No items</p> } <!-- *ngSwitch / @switch --> <div [ngSwitch]="status"> <p *ngSwitchCase="'active'">Active</p> <p *ngSwitchDefault>Unknown</p> </div>
<!-- [ngClass] โ dynamic class binding --> <div [ngClass]="{'active': isActive, 'disabled': !enabled}"> <div [ngClass]="getClasses()"> <!-- method returning object/array/string --> <!-- [ngStyle] โ dynamic inline styles --> <div [ngStyle]="{'color': textColor, 'font-size.px': fontSize}"> <!-- [class.xxx] โ cleaner single class binding --> <div [class.active]="isActive" [class.error]="hasError"> <!-- [style.xxx] โ cleaner single style binding --> <div [style.color]="primaryColor" [style.fontSize.px]="size"> <!-- ngModel โ two-way binding (needs FormsModule) --> <input [(ngModel)]="username" (ngModelChange)="onNameChange($event)">
@Directive({ selector: '[appHighlight]', // used as attribute standalone: true }) export class HighlightDirective { @Input('appHighlight') color = 'yellow'; @Input() defaultColor = 'white'; constructor(private el: ElementRef, private renderer: Renderer2) {} // HostListener: listen to DOM events on the host element @HostListener('mouseenter') onMouseEnter() { this.highlight(this.color); } @HostListener('mouseleave') onMouseLeave() { this.highlight(this.defaultColor); } private highlight(color: string) { // โ Use Renderer2, NOT direct DOM โ works in SSR! this.renderer.setStyle(this.el.nativeElement, 'backgroundColor', color); } } <!-- Usage --> <p appHighlight="cyan" defaultColor="white">Hover me!</p>
<!-- #ref on element โ get DOM element --> <input #nameInput type="text"> <button (click)="nameInput.focus()">Focus</button> <!-- #ref on component โ get component instance --> <app-child #child></app-child> <button (click)="child.reset()">Reset Child</button> <!-- ng-template: a lazy template, not rendered by default --> <ng-template #loadingTpl> <div class="spinner">Loading...</div> </ng-template> <!-- ng-container: grouping without adding DOM element --> <ng-container *ngIf="isLoggedIn"> <app-nav></app-nav> <app-header></app-header> </ng-container> <!-- Render ng-template dynamically --> <ng-container *ngTemplateOutlet="loading ? loadingTpl : contentTpl"> </ng-container>
Data Binding
The 4 types of binding and when to use each. Plus advanced patterns.
<!-- 1. Interpolation: component โ view (one-way) --> <h1>{{title}}</h1> <p>{{1 + 1}}</p> <!-- expressions OK --> <p>{{user?.name | uppercase}}</p> <!-- optional chaining + pipe --> <!-- 2. Property binding: component โ view (one-way) --> <img [src]="imageUrl" [alt]="imageAlt"> <button [disabled]="isLoading">Save</button> <app-user [user]="currentUser"></app-user> <!-- 3. Event binding: view โ component (one-way) --> <button (click)="save()">Save</button> <input (input)="onInput($event)" (blur)="validate()"> <div (keydown.enter)="submit()" (keydown.escape)="cancel()"> <!-- 4. Two-way binding: both directions --> <input [(ngModel)]="username"> <!-- [(ngModel)] is syntax sugar for: --> <input [ngModel]="username" (ngModelChange)="username=$event"> <!-- Custom two-way on your own component: --> <app-counter [(count)]="myCount"></app-counter> <!-- Requires: @Input() count + @Output() countChange -->
<!-- async pipe: auto-subscribes and unsubscribes! --> <div *ngIf="user$ | async as user"> {{user.name}} </div> <!-- Multiple async with one subscription --> <ng-container *ngIf="{users: users$ | async, loading: loading$ | async} as vm"> <div *ngIf="!vm.loading"> <div *ngFor="let u of vm.users">{{u.name}}</div> </div> </ng-container> <!-- Safe navigation operator ?. --> {{user?.address?.city}} <!-- won't crash if user is null --> <p>{{items?.[0]?.name}}</p> <!-- Non-null assertion --> {{user!.name}} <!-- tell TS: "I know this isn't null" -->
- Auto-unsubscribes on component destroy (no memory leaks)
- Triggers change detection when value arrives
- Works with both Observables and Promises
Services & Dependency Injection
Services are where your business logic, API calls, and shared state live. DI is Angular's system for providing them.
// providedIn: 'root' โ SINGLETON, app-wide (most common) @Injectable({ providedIn: 'root' }) export class UserService { ... } // providedIn: 'any' โ one instance per lazy-loaded module @Injectable({ providedIn: 'any' }) export class CacheService { ... } // Component-level: new instance per component @Component({ providers: [FormService] }) // fresh instance each time // Multiple injection tokens const API_URL = new InjectionToken<string>('api-url'); // Inject pattern (Angular 14+ โ no constructor needed!) export class UserComponent { private userService = inject(UserService); // โ Clean! private router = inject(Router); private apiUrl = inject(API_URL); }
@Injectable({ providedIn: 'root' }) export class UserService { private http = inject(HttpClient); // State: BehaviorSubject holds current value private users$ = new BehaviorSubject<User[]>([]); // Public read-only observable readonly users = this.users$.asObservable(); loadUsers(): Observable<User[]> { return this.http.get<User[]>('/api/users').pipe( tap(users => this.users$.next(users)), // update state catchError(err => { console.error(err); return of([]); // recover gracefully }) ); } getUserById(id: string): Observable<User> { return this.http.get<User>(`/api/users/${id}`); } }
RxJS & Observables
The most powerful and most misunderstood part of Angular. This is where Angular developers truly level up.
// Subject: no initial value, only future values const events$ = new Subject<string>(); // BehaviorSubject: has current value, emits it immediately on subscribe const user$ = new BehaviorSubject<User | null>(null); user$.getValue(); // get current value synchronously user$.next(newUser); // emit new value user$.asObservable(); // expose as read-only // ReplaySubject: replays last N values to new subscribers const history$ = new ReplaySubject<string>(5); // last 5 // AsyncSubject: only emits LAST value when complete() const result$ = new AsyncSubject<number>(); // Signal-based (Angular 17+) const count = signal(0); const doubled = computed(() => count() * 2); effect(() => console.log(`Count: ${count()}`));
import { map, filter, switchMap, mergeMap, exhaustMap, debounceTime, distinctUntilChanged, catchError, takeUntil, takeUntilDestroyed, tap, combineLatest, forkJoin, of, from, startWith, withLatestFrom } from 'rxjs'; // TRANSFORMATION source$.pipe(map(x => x * 2)) // transform each value source$.pipe(filter(x => x > 0)) // filter values source$.pipe(tap(x => console.log(x))) // side effect, don't transform // FLATTENING (THE IMPORTANT ONES) // switchMap: cancels previous inner obs, use for search/autocomplete search$.pipe(switchMap(q => this.api.search(q))) // mergeMap: runs all in parallel, use when ORDER doesn't matter ids$.pipe(mergeMap(id => this.api.load(id))) // concatMap: waits for each, use for sequential operations actions$.pipe(concatMap(a => this.api.save(a))) // exhaustMap: ignores new until current completes โ use for login button loginClick$.pipe(exhaustMap(() => this.auth.login(creds))) // COMBINATION combineLatest([a$, b$]).pipe(map(([a, b]) => a + b)) // latest of all forkJoin([api1$, api2$]) // wait for all to complete (like Promise.all) // TIME search$.pipe(debounceTime(300), distinctUntilChanged()) // search input! // CLEANUP โ CRITICAL, prevents memory leaks private destroy$ = new Subject<void>(); ngOnDestroy() { this.destroy$.next(); this.destroy$.complete(); } source$.pipe(takeUntil(this.destroy$)).subscribe(...) // Angular 16+ โ automatic cleanup via DestroyRef source$.pipe(takeUntilDestroyed(this.destroyRef)).subscribe(...)
- switchMap โ Autocomplete/search: cancel old, use latest query
- mergeMap โ Parallel downloads: run all at once, order doesn't matter
- concatMap โ Save queue: wait for each save before starting next
- exhaustMap โ Login/submit button: ignore rapid clicks, wait for first
Routing & Route Guards
URL-to-component mapping, lazy loading, guards, resolvers, and advanced routing patterns.
const routes: Routes = [ { path: '', redirectTo: '/home', pathMatch: 'full' }, { path: 'home', component: HomeComponent }, // Route params { path: 'users/:id', component: UserDetailComponent }, // Query params: /search?q=angular&page=2 { path: 'search', component: SearchComponent }, // Lazy loading module (old way) { path: 'admin', loadChildren: () => import('./admin/admin.module').then(m => m.AdminModule) }, // Lazy loading standalone (new way) { path: 'dashboard', loadComponent: () => import('./dashboard/dashboard.component').then(c => c.DashboardComponent) }, // With guards { path: 'settings', component: SettingsComponent, canActivate: [authGuard], canDeactivate: [unsavedChangesGuard], resolve: { userData: userResolver } }, // Nested routes { path: 'products', component: ProductsComponent, children: [ { path: '', component: ProductListComponent }, { path: ':id', component: ProductDetailComponent } ] }, { path: '**', component: NotFoundComponent } ];
// canActivate: block unauthenticated access export const authGuard: CanActivateFn = (route, state) => { const auth = inject(AuthService); const router = inject(Router); if (auth.isLoggedIn()) return true; return router.createUrlTree(['/login'], { queryParams: { returnUrl: state.url } }); }; // canActivateChild: protects all child routes export const adminGuard: CanActivateChildFn = () => inject(AuthService).hasRole('admin'); // canDeactivate: warn before leaving with unsaved changes export const unsavedGuard: CanDeactivateFn<EditComponent> = (component) => { if (!component.isDirty) return true; return confirm('Unsaved changes. Leave anyway?'); }; // resolve: pre-fetch data before activating route export const userResolver: ResolveFn<User> = (route) => { return inject(UserService).getUserById(route.params['id']); }; // Access in component: route.snapshot.data['userData']
export class UserComponent { private router = inject(Router); private route = inject(ActivatedRoute); // Navigate programmatically goToUser(id: string) { this.router.navigate(['/users', id]); this.router.navigate(['/search'], { queryParams: { q: 'angular' }, queryParamsHandling: 'merge' // keep existing params }); } // Read route params (reactive โ auto-updates) userId$ = this.route.paramMap.pipe( map(params => params.get('id')!) ); // Read query params search$ = this.route.queryParamMap.pipe( map(p => p.get('q') ?? '') ); // Snapshot (static โ only reads once) id = this.route.snapshot.params['id']; // Resolved data user = this.route.snapshot.data['userData']; // Subscribe to navigation events constructor() { this.router.events.pipe( filter(e => e instanceof NavigationEnd) ).subscribe(e => console.log(e)); } }
Forms โ Template & Reactive
Two complete systems. Reactive forms are preferred for complex/dynamic forms. Template forms for simpler cases.
export class UserFormComponent { private fb = inject(FormBuilder); form = this.fb.group({ name: ['', [Validators.required, Validators.minLength(3)]], email: ['', [Validators.required, Validators.email]], age: [null, [Validators.min(18), Validators.max(100)]], address: this.fb.group({ // nested group street: [''], city: ['', Validators.required] }), skills: this.fb.array([]) // dynamic array }); // Access controls get nameCtrl() { return this.form.get('name')!; } get skills() { return this.form.get('skills') as FormArray; } // Add to FormArray dynamically addSkill() { this.skills.push(this.fb.control('', Validators.required)); } // Patch vs setValue loadUser(user: User) { this.form.patchValue(user); // partial update OK this.form.setValue({ name: user.name, email: user.email, ... }); // ALL fields } // Listen to changes constructor() { this.form.get('name')!.valueChanges.pipe( debounceTime(300), distinctUntilChanged() ).subscribe(v => console.log(v)); } onSubmit() { if (this.form.invalid) { this.form.markAllAsTouched(); return; } console.log(this.form.getRawValue()); } }
// Sync validator function function noSpacesValidator(control: AbstractControl): ValidationErrors | null { if (control.value?.includes(' ')) return { noSpaces: true }; return null; } // Cross-field validator (on FormGroup) function passwordMatchValidator(group: AbstractControl) { const pass = group.get('password')?.value; const confirm = group.get('confirmPassword')?.value; return pass === confirm ? null : { passwordMismatch: true }; } // Async validator (e.g. check if username taken) function usernameAvailable(userService: UserService): AsyncValidatorFn { return (control) => timer(300).pipe( switchMap(() => userService.checkUsername(control.value)), map(taken => taken ? { usernameTaken: true } : null) ); } // Use them name: ['', [Validators.required, noSpacesValidator], [usernameAvailable(this.userService)]] // 3rd arg = async
Pipes & Custom Pipes
{{ title | uppercase }}
{{ name | lowercase }}
{{ title | titlecase }}
{{ price | currency:'INR':'symbol':'1.2-2' }}
{{ num | number:'1.2-3' }}
{{ percent | percent:'1.0-2' }}
{{ date | date:'dd/MM/yyyy' }}
{{ date | date:'short' }}
{{ date | date:'fullDate' }}
{{ obj | json }}
{{ array | slice:0:5 }}
{{ observable$ | async }}
{{ 'hello' | i18nSelect: {m:'Mr',f:'Ms'} }}
{{ items | keyvalue }}
@Pipe({ name: 'truncate', standalone: true, pure: true // default: only recalculates when input REFERENCE changes // pure: false โ recalculates on EVERY change detection (expensive!) }) export class TruncatePipe implements PipeTransform { transform(value: string, maxLength = 100, suffix = '...'): string { if (!value) return ''; return value.length > maxLength ? value.substring(0, maxLength) + suffix : value; } } <!-- Usage --> {{ description | truncate:50 }} {{ description | truncate:100:' [more]' }}
Lifecycle Hooks
export class MyComponent implements OnChanges, OnInit, DoCheck, AfterContentInit, AfterContentChecked, AfterViewInit, AfterViewChecked, OnDestroy { // 1. Called when @Input() values change (even before OnInit) ngOnChanges(changes: SimpleChanges) { if (changes['userId']?.currentValue) { this.loadUser(changes['userId'].currentValue); } } // 2. Called ONCE after first ngOnChanges โ your main init hook ngOnInit() { this.loadData(); } // 3. Every CD cycle (expensive โ use sparingly) ngDoCheck() { } // 4. After projected content (ng-content) init ngAfterContentInit() { } // 5. After every CD check of projected content ngAfterContentChecked() { } // 6. After component's VIEW and child views are initialized // โ @ViewChild properties available HERE ngAfterViewInit() { this.chart.render(); } // 7. After every check of view and child views ngAfterViewChecked() { } // 8. Cleanup โ unsubscribe, clear timers ngOnDestroy() { this.destroy$.next(); this.destroy$.complete(); clearInterval(this.timer); } }
Performance & Optimization
Default strategy: Angular checks every component on EVERY event (click, timer, HTTP). With a large app, this is thousands of checks.
OnPush strategy: Component only re-renders when: an @Input() reference changes, an event from inside the component fires, an async pipe emits, or you call markForCheck() manually.
@Component({ changeDetection: ChangeDetectionStrategy.OnPush }) export class UserCardComponent { // โ GOOD: new object reference triggers re-render @Input() user!: User; // In parent: always create NEW objects/arrays updateUser() { this.user = { ...this.user, name: 'New Name' }; // โ new ref // this.user.name = 'New Name' โ โ same ref, won't trigger! } }
import { PreloadAllModules, NoPreloading } from '@angular/router'; providers: [ provideRouter(routes, withPreloading(PreloadAllModules), // lazy load all in background // withPreloading(NoPreloading) // never preload // custom: only preload flagged routes ) ] // Custom preloading strategy const routes = [ { path: 'admin', loadChildren: () => import('./admin/admin.module'), data: { preload: true } // custom flag } ]; // deferrable views (Angular 17+) @defer (on viewport; prefetch on idle) { <heavy-component /> } @loading { <spinner /> } @placeholder { <div>Coming soon...</div> }
HTTP Client & Interceptors
export class ApiService { private http = inject(HttpClient); getUsers(): Observable<User[]> { return this.http.get<User[]>('/api/users', { params: { page: '1', limit: '20' }, headers: { 'X-Custom': 'value' } }); } // Observe full response (headers, status code, etc) getWithHeaders() { return this.http.get<User>('/api/me', { observe: 'response' }).pipe( tap(resp => { console.log(resp.status); // 200 console.log(resp.headers.get('X-Token')); console.log(resp.body); // User object }) ); } // Upload with progress upload(file: File) { const fd = new FormData(); fd.append('file', file); return this.http.post('/api/upload', fd, { observe: 'events', reportProgress: true }).pipe( filter(e => e.type === HttpEventType.UploadProgress), map(e => Math.round(100 * e.loaded / (e.total ?? 1))) ); } }
// Auth interceptor โ add Bearer token to every request export const authInterceptor: HttpInterceptorFn = (req, next) => { const token = inject(AuthService).getToken(); if (!token) return next(req); return next(req.clone({ setHeaders: { Authorization: `Bearer ${token}` } })); }; // Error interceptor โ global error handling export const errorInterceptor: HttpInterceptorFn = (req, next) => { return next(req).pipe( catchError((err: HttpErrorResponse) => { if (err.status === 401) inject(Router).navigate(['/login']); if (err.status === 0) inject(NotifyService).error('No connection'); return throwError(() => err); }) ); }; // Register in app.config.ts provideHttpClient(withInterceptors([authInterceptor, errorInterceptor]))
Signals (Angular 17+)
The new reactivity model. More predictable than RxJS for simple state. The future of Angular's state management.
export class CounterComponent { // signal() โ writable reactive state count = signal(0); items = signal<string[]>([]); // computed() โ derived, auto-updates when deps change doubled = computed(() => this.count() * 2); isEmpty = computed(() => this.items().length === 0); // effect() โ side effects when signals change (like useEffect) constructor() { effect(() => { console.log(`Count changed to: ${this.count()}`); // Auto-tracks which signals are read inside }); } increment() { this.count.update(c => c + 1); // based on current this.count.set(10); // set directly } addItem(item: string) { this.items.update(list => [...list, item]); // immutable! } // Convert Observable โ Signal userSignal = toSignal(this.userService.user$, { initialValue: null }); // Convert Signal โ Observable count$ = toObservable(this.count); }
export class UserCardComponent { // Signal inputs โ auto reactive, no @Input() decorator userId = input.required<string>(); // required theme = input('dark'); // optional with default // Model signal โ two-way binding (like ngModel) value = model(''); // two-way [(value)] // Signal output selected = output<string>(); // Computed from signal input displayName = computed(() => `User: ${this.userId()}` // call input like function ); onSelect() { this.selected.emit(this.userId()); } } <!-- Template usage --> <app-user-card [userId]="'123'" [theme]="'light'" [(value)]="myValue" (selected)="onSelected($event)" />